Skip to content

web: 修复移动端布局与首次运行死胡同,补齐可访问性地板,客户端脱离模板字符串 - #375

Open
oratis wants to merge 12 commits into
claude/opt-billingfrom
claude/opt-web-ui
Open

web: 修复移动端布局与首次运行死胡同,补齐可访问性地板,客户端脱离模板字符串#375
oratis wants to merge 12 commits into
claude/opt-billingfrom
claude/opt-web-ui

Conversation

@oratis

@oratis oratis commented Sep 7, 2026

Copy link
Copy Markdown
Owner

第 7 / 8 条,实现 UX 审查的两个 P0 与 UX-3/4/5/8/10/11 以及技术审查的 T-2、T-10。基于 #374

两个 P0

移动端布局(UX-2)。 #367 让右栏默认收起,但 body.rb-collapsed .frame 的特异性高于 720px 媒体查询,375px 宽屏上主区只剩 75px;即便展开,功能栏 12 个图标也把聊天视图撑到 681px,发送按钮落在屏幕外。现在收起规则限定在 ≥721px,#viewChat 固定 minmax(0,1fr),功能栏在窄屏下减到 5 个按钮并可横向滚动。

实测 375×812(收起与展开两种状态一致):

指标 修复前 修复后
.main 宽度 75px 375px(= 视口)
发送按钮 屏外 left 277–361,可见可点
文档 / 主区横向溢出

首次运行死胡同(UX-1)。 填错 key 后:出生仪式对 401 也重试、把 Anthropic 的原始 JSON 直接显示给用户、点 ENTER 只是刷新页面用同一个错 key 再跑一遍,key 表单再也不出现。现在错误一律是人话,认证错误附 Change key 按钮回到密钥面板(重配置模式),保存成功后不刷新页面直接重启出生流;运行期间可 Cancel。

其他

  • 可访问性地板(UX-3)。 全局 :focus-visible 焦点环(此前 outline: none,键盘用户完全看不到焦点);两套主题的 --fg-3 提到 AA 以上(Calm 3.2:1 → 5.62:1,Nebula 4.4:1 → 5.76:1);最小字号 10px → 11.5px;窄屏下小控件用透明 ::after 撑到 44pt 触控区;聊天流与待办计数加 aria-live;非必要动效包进 prefers-reduced-motion。对比度由单元测试从 CSS 中解析令牌并断言 ≥4.5。
  • 空态(UX-4)。 0 消息的会话显示为 "New session · 刚刚"(此前显示原始 id),空聊天页给出身份行、三个可点击的起手提示和三行能力说明。
  • 右栏可发现性(UX-5)。 收起时切换按钮显示待办数徽标;首次出现待批准且用户从未手动切换过时自动展开一次。
  • provider 选择器(UX-6)。 密钥面板与设置页不再只认 Anthropic。
  • 语言(UX-8)。 33 处中文字面量与英文界面混排 → 单一 t(key) 表(en / zh-CN),按 navigator.language 选择并同步 lang 属性。
  • 连接状态(UX-10)。 45 秒无 SSE 字节显示 "reconnecting…";发送后 2 秒无 turn_start 显示等待指示。
  • 快捷键(UX-11)。 ⌘K 会话切换器、⌘/ 聚焦输入、⌘F 查找、Esc 关闭、? 帮助。

结构(T-2)

客户端 JS 与 CSS 从模板字符串移入真实文件(src/web/assets/client/main.{js,css})。之前每个正则的反斜杠都要手工加倍、注释里一个反引号就会静默截断字符串(编辑过程中发生过两次),且 typecheck 完全看不见这 7000 行。现在 npm run typecheck:client 用 DOM lib 检查真实文件;单文件 HTML 交付这一产品承诺不变(构建期内联)。字节快照测试改为组合校验,UI 改动不再需要重钉哈希。

CSP(T-10)

外壳在十多处渲染不可信文本(工具结果、邮件主题、知识库摘录、被观察 agent 的 Markdown 输出)。转义是第一道防线且已经到位,但离出漏洞只差一次 review 疏忽,而这里的漏洞将以能驱动 agent 的页面权限执行。现在每次响应生成 nonce,注入的 <script> 无法执行。真实浏览器验证:外壳渲染、Room iframe 加载、两段内联脚本均执行、控制台零 CSP 违规。

docs/DESIGN_TOKENS.md 记录两套色板、字号阶梯、间距、焦点环、断点与实测对比度表。

🤖 Generated with Claude Code

oratis and others added 12 commits September 7, 2026 12:22
…e chat column fit its pane (UX-2)

Two stacked failures made the web shell unusable on phones since the right
panel started collapsed by default (#367):

- body.rb-collapsed .frame (specificity 0,1,1) outranked the ≤720px
  single-column .frame rule (0,0,1), so a 375px viewport got the desktop
  "300px 1fr" grid: sidebar 300px, chat 75px. The collapse rule now lives
  inside @media (min-width: 721px); the ≤720px block owns the phone layout
  and the panel stays display:none at every width as before.
- #viewChat's implicit auto grid column could never be narrower than the
  function bar's min-content (10 flex:none icon buttons + chip + find box ≈
  680px), so #log / #form / #fnbar grew to 681px inside any pane narrower
  than that and .main (overflow:hidden) clipped the send button away. This
  also hit tablets: at 768px the pane is 468px wide and #sendBtn sat at
  x=865–961. The column is now minmax(0, 1fr) and .fnbar is a min-width:0
  overflow-x:auto strip, so it scrolls inside the pane instead of widening it.

At ≤720px the bar additionally drops the five quick-panel buttons (all
reachable from the Memory view; pairing is loopback-only anyway) and the
right-panel toggle (nothing to toggle there), so chip · KB · mail · find ·
theme fit a 375px row without scrolling; log/composer padding tightens and
.frame uses 100dvh where supported.

Measured in headless Chromium 151 against a born-soul scratch instance
(before → after):
  375×812, rail collapsed (default):
    .frame columns 300px 75px → 375px; .main width 75 → 375;
    .main scrollWidth/clientWidth 681/75 → 375/375;
    document scrollWidth/innerWidth 375/375 → 375/375;
    #sendBtn x 800–884 (off-screen) → 277–361 visible; #log/#form 681 → 375.
  375×812, rail open: .main scrollWidth 681/375 → 375/375; #sendBtn 583–667
    (off-screen) → 277–361 visible.
  768×900: #sendBtn 865–961 (off-screen) → 652–748 visible; .fnbar scrolls
    528/468 instead of widening the column.
  1024 and 1440 (both rail states): unchanged (.main 724 / 1140 / 820).

lisa-html-snapshot.test.ts: byte pin recomputed (310580 bytes).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit cad0f222af235c70fa4df8c467d00feb36c56ceb)
…, live regions (UX-3)

The shell had no visible keyboard focus at all, secondary text sat below the
WCAG AA 4.5:1 floor in both themes, and a dozen labels were rendered at 8.5–11px
where a 13" laptop at 100% zoom already loses them. Screen readers got nothing
when Lisa started or finished a turn, and the "needs you" count changed silently.

Focus
- New --focus-ring / --focus-ring-offset tokens and one global :focus-visible
  rule (2px solid var(--accent), 2px offset). :focus-visible, not :focus, so a
  mouse click never paints a ring. Tree rows are real <button>s (makeLisaLeaf /
  makeAgentLeaf), so they are covered by the same rule. Text fields keep their
  more specific accent-border + halo :focus treatment.

Contrast (measured with the WCAG 2.x formula, alpha-composited over the surface
each token is actually painted on — numbers from the new lisa-css.test.ts):
- Nebula --fg-3 #6c7398 → #8189ae: bg-deep 4.34→5.76, bg-1 4.15→5.50,
  bg-card 3.86→5.12, bg-3 chips 3.44→4.57.
- Calm --fg-3 #8a919f → #5f6878: white 3.15→5.62, bg-deep 5.24, bg-2 4.92,
  bg-3 4.66.
- --fg-faint is now decorative-only (pips, rules, disabled fills). The three
  places it was used as copy — .session-empty, .view-empty, .modal-body .empty,
  plus the .ttime/.stime timestamps — moved to --fg-3.

Type scale
- Every text rule below 11.5px raised to 11.5px (~60 rules: badges, chips,
  timestamps, stat captions at 8.5px, role labels, kv rows, cfg labels…).
  Three rules stay small because they draw glyphs, not text: .twist (the 8px
  tree arrow), .agent-glyph.mini (a single letter in a 14px box), and the
  unread dot. lisa-css.test.ts walks every font-size in the sheet and fails on
  a new offender outside that allowlist.

Touch targets
- .fbtn 34→36px; .new-btn 24×21 → 36×28; .session-ctrl .mc and .attach-rm get
  min-height/min-width. At ≤720px an inset ::after on each small control
  extends the hit area to ≥44px (WCAG 2.5.8) without growing the visible
  button — the controls are position:relative and ::after was unused.

Announcements
- #chatStatus (role=status, aria-live=polite, .sr-only) carries coarse turn
  state only — "Lisa is thinking / is replying / finished replying / the
  request failed". Deliberately not the streamed text, which would be
  re-announced on every paint.
- #sbNeedsCount is aria-live and now appends a visually hidden noun, so it
  reads "2 agents need you" instead of "2".
- The off-screen #fileInput gets tabindex=-1; it is opened from the + menu and
  was a dead Tab stop.

Motion
- One @media (prefers-reduced-motion: reduce) block silences the looping
  animations (pips, stars, record pulse, typewriter cursor), the reveal
  transitions and #log's smooth scroll. Chose the reduce-override form over
  wrapping each animation in no-preference: same effect, one place to add the
  next selector, and no risk of missing a declaration. State stays legible
  without motion — pips keep their colour, the record button stays red.

New src/web/lisa-css.test.ts parses the token blocks out of the CSS string and
asserts the ratios, the focus-ring rule and token, the 36px icon-button floor,
the 11.5px minimum and the reduced-motion block, so a future "let's soften the
secondary text" fails loudly. The byte-pin in lisa-html-snapshot.test.ts is
re-pinned (310580 → 315861 bytes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 01143e212a0b8cc7feadb9d78fe42078b9381244)
…rst screen (UX-4)

Two holes right after the birth ritual. (a) A session with no messages was
labelled with its raw id — "20260905-220846-9f7d58" in the tree, the context
chip, the title bar and the inspector, four places showing a string no human
reads. (b) #log had zero children, so the first thing a new user saw was an
empty pane whose only hint was the composer placeholder.

Naming
- sessionLabel() falls back to "New session · <relative time>" when
  messageCount is 0 instead of the id; the first user message still wins the
  moment there is one. Measured on a scratch instance: tree leaf, context chip
  and title bar all read "New session · 2m".
- The id is demoted, never dropped: tree leaf title (already), new context-chip
  title, new title-bar title, and the inspector's existing sub line
  ("<id> · <cwd>"). The title bar previously read "Lisa · 20260905-…"; it is
  "Lisa · New session · 2m" with the id one hover away, which is what anyone
  matching a session against ~/.lisa/sessions actually needs.
- setActiveSessionUI runs before /api/sessions lands, so the title bar asks the
  sidebar closure through window.lisaSessionLabel and repaints from
  renderSessionUI on every list refresh; it falls back to the id meanwhile.

Empty chat card (#chatEmpty, .chat-empty)
- One identity line reusing what the sidebar already fetched — "Lisa · born
  2026-05-01 · 128 days" from #identitySub, no extra request.
- Three clickable starters. The first names her actual current pursuit when the
  soul has one ("How is \"understand the codebase I live in\" going?" on the
  scratch soul), so the card is about THIS Lisa, not a generic tour; without a
  desire it degrades to "What's on your mind right now?". Clicking FILLS the
  composer and focuses it — it never sends, so a mis-click cannot spend a model
  call.
- Three ability lines (tools / knowledge / mail).
- It is drawn only after the first /api/history answer (an empty #log before
  that just means the fetch is in flight) and is retired by the first real node
  in the log: removeChatEmpty() sits in el(), in prependHistoryMessages, and in
  the three idle SSE paths that append to #log directly. It repaints when
  /api/soul and /api/island/ping land so the identity line and the desire-based
  starter are not stale.

Verified in headless chromium at 1440×900 against a scratch instance (seeded
soul, placeholder key, --no-idle --no-reflect --no-mcp --no-plugins, port 5871,
no model call): card 560px wide and centred, all three starters render, a click
put the desire prompt in #input, zero console errors, and the first Tab stop
paints the new 2px rgb(106,212,255) focus ring at 2px offset.

New tests extract the served text of sessionLabel out of MAIN_CLIENT_JS and run
it in a vm sandbox, so the assertions are on the exact bytes the browser gets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit bc0ac6ddeebc4ac1aad4a719511cdfa8051b77ae)
…ent is blocked (UX-5)

the inspector, mail, reflection and tokens. The only way back in was a 34px
icon whose tooltip said "Collapse / expand the right panel" — nothing anywhere
told you that two agents were sitting on a permission prompt.

- .fbtn-badge on #fnPanel carries the needs-you count while the rail is
  collapsed (>9 renders "9+"), and the button's title/aria-label become
  "2 agents need you — open the right panel". Both revert to the plain toggle
  text when the count drops to zero or the rail is open, so nothing lingers.
  Warm token, not accent: it means "something is waiting on you", not "this
  control is on". #1a1206 on --warm is 12.8:1 in Nebula and 5.8:1 in Calm.
- window.lisaRightbarAttention(count, needsDecision) is fed from renderNeeds
  on every roster tick. A "waiting" or pendingPermission agent counts as a
  decision; an errored one is badged but does NOT open the rail — an error is
  news, not a question.
- The rail opens itself ONCE per page load, and only for a profile that has
  never driven the toggle. The manual toggle now persists lisaRightbarTouched,
  which permanently disarms the nudge. The auto-expand deliberately does not
  write lisaRightbar, so a reload returns to the collapsed default and one
  blocked agent can never silently become the user's layout — there is a test
  asserting that function body contains no setItem.

Measured in headless chromium at 1440×900 against the scratch instance:
fresh profile → collapsed, no badge; attention(2,false) → badge "2", still
collapsed, .rightbar display:none; attention(2,true) → collapsed cleared,
.rightbar display:flex, badge gone; one manual click → touched=1 stored, and a
subsequent attention(4,true) leaves it collapsed with badge "4". Badge box
15×15px, rgb(255,208,102) on rgb(26,18,6), 11.5px. Zero page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 394926affb741811daea313bfa5217b33c108e2d)
…e, and Cancel (UX-1)

The P0 dead end. A rejected key produced `401 {"type":"error","error":
{"type":"authentication_error",...}}` printed verbatim under the ritual, and
ENTER did location.reload() straight back into the same failing key. The gate
never returned — /api/config/status reports "configured" the moment a key is on
disk — so the only recovery was hand-editing ~/.lisa/config.env. The ritual had
no cancel either: the POST ran to completion whatever the user did.

Errors
- birthErrorCode(ev) understands the new frame ({kind,code,message,retryable})
  and classifies an old server's raw payload by substring — 401/403/
  authentication_error/invalid_api_key/unauthorized → auth, 429/rate_limit →
  rate_limit, timed out/ETIMEDOUT → timeout, ECONNREFUSED/ENOTFOUND/fetch
  failed → network, else unknown. An unrecognised code falls through to
  classification rather than being trusted. Substring tests rather than
  regexes on purpose: this file is a template literal, and every backslash
  would need doubling.
- BIRTH_ERROR_TEXT carries one sentence per class; a test asserts none of them
  contains "{". The provider payload lands on birthError.title — reachable on
  hover and in a bug report, never on screen.

Recovery
- The gate is reopenable: openKeyGate({reconfigure, reason}) retitles it
  "CHANGE · API · KEY", shows the reason banner (#cfgReason) explaining why the
  form is back, and always clears the key field — retyping is the point.
- code "auth" (or retryable:false) offers "Change key"; everything else offers
  "Try again" + "Change key".
- Saving from reconfigure mode restarts the ritual IN PLACE — no
  location.reload(), so the page keeps its SSE connection, its log and its
  scroll. resetBirthUI() clears steps/final/ENTER/error between runs.

Cancel
- The ritual fetch now carries an AbortController. A Cancel button sits in
  #birthActions for the whole run and is withdrawn when the stream ends; it
  aborts the request and returns to the gate with "Cancelled. Set a key and
  Lisa will start again." An AbortError is never rendered as a failure.

Verified in headless chromium against a keyless scratch instance (empty
LISA_HOME, no key, port 5872) with fetch stubbed for the ritual, so no outbound
request and no model call: boot shows the gate focused on the key field; the
raw 401 payload renders as the auth sentence with the JSON only in title and a
single "Change key" that reopens the gate in CHANGE mode with the reason shown;
timeout/network/rate_limit/derived-ECONNREFUSED each render their sentence with
"Try again"+"Change key"; retryable:false collapses to "Change key"; during a
stream the only action is "Cancel" and clicking it aborts the fetch
(signal fired) and reopens the gate; a save from repair mode issued
/api/config/save then /api/birth with no page reload, replayed the steps and
revealed ENTER. Zero page errors throughout.

Known gap for another stream: /api/birth still does not listen for the client
disconnect, so an aborted ritual keeps inferring server-side until it finishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit bd5ef57fdcd27c4f86be7f037981709f86972903)
The gate asked for an ANTHROPIC_API_KEY (required) and an optional
OPENAI_API_KEY. The CLI's `lisa doctor` lists 13 providers and the site
advertises "10+ LLM providers", so a DeepSeek/GLM/Qwen user could not complete
first run in the browser at all — the only route was editing config.env by hand.

- One table drives both surfaces. It is built from
  /api/config/status.providers ({id, envKey, label, modelPrefixes, configured})
  when the server reports one, and otherwise from a built-in list mirroring
  src/providers/registry.ts: Anthropic, OpenAI, DeepSeek, Zhipu GLM, Aliyun
  Qwen, Moonshot Kimi, Google Gemini and a custom OpenAI-compatible base URL.
  Matching between the two is by envKey first — ids belong to the server, the
  environment variable name is the stable identity — so a provider this client
  has never heard of still renders, with a generic placeholder.
- The gate is now PROVIDER / KEY / MODEL, plus BASE URL for the custom entry
  only. The key label shows the actual env var, the console link retargets per
  provider, and a repair keeps the provider that just failed selected.
- The model field is prefilled as a placeholder, not a value, and is only sent
  automatically for providers the server cannot auto-detect. Anthropic and
  OpenAI are resolved from the key alone by resolveDefaultModel(); every other
  provider would otherwise fall back to claude-sonnet-4-6 with a key that
  cannot pay for it, so those pin LISA_MODEL.
- POST /api/config/save carries the new {keys:{ENV:value}, model, baseUrl}
  shape plus every legacy field name the current server reads
  (anthropicKey/openaiKey) and the shorter aliases (anthropic/openai).
- After saving, the client re-reads the status and confirms the key survived.
  An older backend silently drops anything that is not Anthropic or OpenAI, so
  instead of walking into a ritual that cannot succeed the user gets:
  "This Lisa did not keep the DeepSeek key — it only accepts Anthropic and
  OpenAI keys. Update Lisa (npm i -g @oratis/lisa), or add DEEPSEEK_API_KEY=…
  to ~/.lisa/config.env and restart." Confirmation returns null (never blocks)
  when the answer is genuinely unknowable.
- The Settings view lists every provider with a configured/not-set chip and
  uses the same picker + the same guard.

Measured in headless chromium. Against the keyless instance (5872, old-shape
status): 8 options, Anthropic preselected, switching to DeepSeek retitles the
key label to DEEPSEEK_API_KEY and the model placeholder to deepseek-chat,
custom reveals the base-URL field. A DeepSeek save posted
{keys:{DEEPSEEK_API_KEY},model:"deepseek-chat"} and was refused with the note
above (gate stayed open); an Anthropic save posted keys + anthropicKey +
anthropic and closed the gate. Injecting a new-contract status with an unknown
"Brand New Co" provider rebuilt the list to exactly those three and preselected
the configured one. Against the born instance (5871) the Settings view listed
seven providers with chips and a zhipu save posted
{keys:{ZHIPU_API_KEY},model:"glm-4-plus"}. Zero page errors.

Contract note for the server stream: today's handler reads `anthropicKey` /
`openaiKey`, not `anthropic` / `openai` — this client sends all four spellings
plus `keys`, so either naming works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit d0186bad5b571a211707b28f407b9805125e2600)
…terals (UX-8)

The shell shipped as <html lang="en"> with ~30 Chinese literals baked into it —
the knowledge-base button ("💾 存入知识库", "保存中…", "已存入知识库 ✓"), the chat
error block ("⚠ 请求出错", "↻ 重试") — so an English user got Chinese buttons and
a Chinese user got an otherwise English UI. The lang attribute never matched
either.

- LISA_STRINGS carries an `en` and a `zh-CN` table; tr(key, vars) looks up the
  locale, falls back to English for a missing key, returns the key itself
  rather than "undefined" if it is in neither, and interpolates {name}
  placeholders. A test asserts the two tables have identical key sets.
- LISA_LOCALE comes from navigator.language (zh* → zh-CN, everything else →
  en) and is written to document.documentElement.lang, so screen readers and
  hyphenation follow what is actually rendered.
- Routed through it: the former CJK strings, plus everything this stream added
  or touched — chat status announcements, empty-state card, session labels,
  right-rail badge, the whole key gate and every birth error.
- Named tr(), not t(). "t" is already a local variable in twenty places in this
  4000-line file, and sessionLabel's own `const t` silently shadowed the helper
  into a string on the first attempt. A test now scans the cooked bytes for any
  bare `t(` call so the trap cannot come back.
- Two deliberate exceptions, both documented in the source: idleHeaderLabel
  keeps its ja/ko branches (they predate the table, and dropping them would be
  a regression), and the QQ / 163 mailbox setup help keeps 设置 / 服务 / 授权码
  because it is quoting those providers' own Chinese UI labels — translating
  them would make the instructions wrong. The CJK-scan test allowlists exactly
  those two.
- lisa-html.ts loses its two Chinese comments (九宫格 / 功能区).

Measured in headless chromium against the scratch instances with the browser
locale forced. en-US: lang="en", "Say anything — or start here:", tree "New
session · 25m", rail "2 agents need you — open the right panel", gate
"SET · API · KEY" / "ANTHROPIC_API_KEY is required." zh-CN: lang="zh-CN",
"随便说点什么 —— 或者从这里开始:", tree "新会话 · 25m", rail "2 个 agent 在等你 ——
打开右侧面板", gate "设 · 置 · KEY" / "需要填写 ANTHROPIC_API_KEY。". Zero page
errors in either locale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit f367daac66c91080a46bb308d053c092e2e20026)
The daily driver stalled twice during the review (5s+ with no response, then
back to 3–100ms) and the UI said nothing — the only feedback path was a request
failing outright, so a slow backend and a working one looked identical.

Title-bar pill
- #connPill ("reconnecting…", aria-live=polite) sits next to the session tag
  and is hidden by default.
- Liveness is: every /events frame and the stream's own `open` event call
  noteEventBytes(), which hides the pill; es.onerror shows it immediately
  (the existing 3s reconnect then clears it on the next open).
- A 5s checker covers the rest. EventSource readyState is authoritative for a
  closed or reconnecting stream — no request needed. The one case it cannot
  see is a half-open socket (laptop sleep, network change) that still reports
  OPEN while nothing flows; after CONN_QUIET_MS (45s) of silence that is
  settled with a single 4s-timeout GET /health, rate-limited to once per 30s.
  A good answer counts as liveness, a bad one shows the pill.
- Worth recording for the server stream: the ": ping" comment heartbeat keeps
  the socket warm but is invisible to JS — EventSource does not surface
  comments at all — which is exactly why liveness here is readyState + a probe
  rather than a byte timer. A named `ping` event would be visible; a comment is
  not.

Chat waiting state
- /chat normally answers in a few hundred ms. If two seconds pass with no
  frame, the existing "⋯ thinking" line escalates in place to
  "⋯ waiting for the backend" with .thinking.waiting (warm, not italic), so a
  stalled backend reads differently from normal thinking. The first frame
  reverts it, and the turn's finally clears the timer so it can never fire onto
  a finished or failed turn.

Both strings are in the i18n table.

Measured in headless chromium against the scratch instance: connected →
hidden, readyState 1. Closing the stream and running the checker → visible
"reconnecting…". A stubbed half-open socket quiet for 60s with /health healthy
→ stays hidden; with fetch rejecting → visible; the next noteEventBytes()
hides it again. With /chat stubbed to never resolve: at 0.6s the line is
"⋯ thinking" (class "thinking"), at 2.6s it is "⋯ waiting for the backend"
(class "thinking waiting"). Zero page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit ef073dd6617ad38b07c25d5e2a6d6cc1e70a1af9)
…eadable cwd (UX-11)

Three small dishonesties in the shell.

Sense
- A fresh install with no connector still read "Publishing active · Pause
  publishing", which implied Lisa was posting somewhere. The policy row is now
  gated on connectors.length; with none installed it is a neutral state line
  ("Nothing is published — no social connector is installed.") and there is no
  pause button to press.

Pair panel
- The panel already had a Copy button on every row (the review's note predates
  it); what it lacked was any word about the credential's lifetime. Device
  tokens minted by devices.ts have NO expiry — they are valid until the device
  is revoked — so the panel says exactly that, in a warm note under the fields:
  anyone who gets the link can reach this Lisa, revoke the device when done.
  Overstating it ("expires in 10 minutes") would have been worse than silence.

Inspector
- cwd was the full absolute path (90+ chars here), ellipsised into nothing in a
  320px rail. abbrevPath() collapses /Users/<name> and /home/<name> to ~ (and
  the home directory itself to "~"), leaving anything else untouched. The full
  path stays in the tooltip and is one click away via a new .copy-btn, which is
  dim until the row is hovered or the button takes focus. The inspector's sub
  line is abbreviated the same way. No regex — this file is a template literal
  and every backslash would need doubling.

Measured in headless chromium against the scratch instance: abbrevPath maps
/Users/oratis/Projects/LISA/… → ~/Projects/LISA/…, /Users/oratis → ~,
/home/deploy/app → ~/app, /opt/lisa unchanged, "" and null → "". The cwd row
renders "~/Projects/LISA/.claude/worktrees/agent-a24374ea4d601d4d1" with the
absolute path in title, and clicking Copy put the absolute path on the
clipboard and flipped the label to "Copied". Sense rendered
"social-policy neutral" with no #socialPauseBtn. The pair panel showed Copy on
all four rows plus the lifetime note. Zero page errors.

All new copy goes through the i18n table (en + zh-CN).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 99ecb17823c8fea877a67f33fbc1079602a046c9)
…help (UX-11)

The shell had Enter, Shift+Enter and Esc. Switching sessions meant reaching for
the sidebar tree; find-in-chat meant spotting a 36px magnifier in a
twelve-icon bar.

- ⌘K / Ctrl+K opens a minimal session switcher: a filter field over the same
  session cache the tree uses, ↑/↓ to move, Enter to activate, Esc to close,
  click (mousedown, so the overlay closes before the click could land on what
  is underneath) to pick. The active session is tinted, each row carries
  "<n> msgs · <age>", and the raw id is the row tooltip. The list is built
  from window.lisaSessionsForSwitcher(), pre-rendered inside the sidebar
  closure so the top-level switcher needs no access to sessionLabel /
  relativeTime / cachedSessions.
- ⌘/ focuses the composer. ⌘F opens the find bar and selects it — deliberately
  shadowing the browser's own find, which searches a virtualised log and the
  whole chrome around it.
- "?" opens the shortcut list, but only when the user is not typing into a
  field, so a literal question mark still reaches the composer.
- Esc closes the switcher, then the find bar, most-nested first. It
  deliberately does NOT close the key gate or the birth overlay: dismissing
  those drops the user into a shell that cannot work. There is a test asserting
  neither id appears in that handler.
- The list is also reachable without knowing it exists: a "Keyboard shortcuts"
  row in Settings → About opens the same modal.

Measured in headless chromium against a scratch instance with three sessions:
⌘K opened the overlay and focused the filter with the active session
preselected; ArrowDown moved the selection; typing "zzzz" showed "No sessions
match."; Esc closed it; ArrowDown+Enter switched window.lisaActiveSessionId to
the second row's id; ⌘/ focused #input; ⌘F revealed and focused #fnFind and Esc
hid it; "?" opened the KEYBOARD modal listing all six bindings; "?" with the
composer focused typed a "?" and opened nothing. Zero page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1645f8022bf00a2ec3f4870e49c3028616422dc0)
…es (T-2)

src/web/lisa-client.ts was a 4,700-line JavaScript program inside an untagged
template literal, and lisa-css.ts a 3,000-line stylesheet in another. The cost
was not cosmetic:

- Every backslash in a regex had to be doubled, because the literal ate one
  pass of escaping. lisa-client.test.ts exists entirely because that bit the
  idle-note sentinel.
- A backtick anywhere — including inside a comment — silently ended the
  literal. It happened twice while writing the commits before this one
  (`configured` in a prose comment, then a JSON payload quoted in another).
- No editor could highlight, fold, format or lint any of it, and no type
  checker could see it at all.

The bytes now live at src/web/assets/client/main.js and main.css, byte-identical
to what the template literals produced (verified: MAIN_HTML hashed to the same
sha256 immediately after the extraction, before any edit). lisa-client.ts and
lisa-css.ts are four-line modules that readFileSync them at module load,
resolved from import.meta.url so the same code works under tsx (src/web/…) and
from the compiled build (dist/web/…, where `assets` is a symlink in development
and a real copy in the published package — copy-assets / prepublishOnly). Their
exports keep the same names, so lisa-html.ts and every existing test are
unchanged.

Single-file HTML delivery is a product promise — one GET, no secondary
requests, a saved copy still runs — so this is a SOURCE split, not a runtime
one: the page still inlines both files exactly as before.

Deviation from the brief worth naming: the readFileSync calls sit in
lisa-client.ts / lisa-css.ts rather than in lisa-html.ts. Doing it in
lisa-html.ts would have meant either a circular import (the other two modules
are imported by it) or a third module; keeping each asset owned by the module
that already exported it is smaller and leaves every import site untouched.

Tests
- html-syntax.test.ts (vm.Script compile of every inline <script>) is kept
  as-is; it is the check that matters most now that an editor will let anyone
  type into the file.
- lisa-html-snapshot.test.ts stops pinning bytes. The length+sha256 pin did its
  job while the split was in flight, but with the client in real files it was
  pure churn — every one-word copy change failed two tests and rewrote a hash
  that proves nothing about behaviour (it was re-pinned nine times in this
  branch alone). It now asserts the property the pin stood in for: the file
  list in assets/client is exactly {main.css, main.js}, neither contains a
  </script or </style sequence, the exported constants equal the file contents,
  the served page contains each file verbatim exactly once and inside the right
  element, there is no <script src> or <link rel=stylesheet>, and the document
  is complete with balanced style/script tags.
- The obsolete "double-escape in the template literal" commentary in
  lisa-client.test.ts is rewritten: the trap is gone, the behavioural check
  stays.

Type checking
- New tsconfig.client.json (allowJs, checkJs, ES2022 + DOM, noEmit, strict
  false) wired as `npm run typecheck:client`. It surfaced 320 errors; it now
  reports ZERO.
- ~150 were the client's own architecture, not defects: window.lisa* globals
  (about forty names that let the top-level script and the two IIFE blocks talk),
  the source-injected renderMarkdown, and the forward-declared
  updateReflection / refreshClaudeSessions. types/web-client.d.ts declares them,
  which is documentation as much as a fix.
- ~150 were DOM narrowing a plain .js file cannot express: getElementById
  returns HTMLElement so `.value` / `.disabled` / `.placeholder` are errors on
  every form control, and e.target is an EventTarget so `.closest()` is an
  error in every delegated handler. Index signatures on Element / EventTarget /
  Event drop exactly that class and keep everything else — crucially TS2304,
  unknown identifiers, which is this file's real bug class (a renamed-but-not-
  updated global; that is what the tr() rename hit earlier in this branch, and
  no test would have caught it).
- The rest were fixed in the code: five click handlers used `this` inside a
  function expression (now ev.currentTarget, which also survives a future
  arrow-function conversion), the provider-hint fallback was a bare `{}` that
  hid which fields the merge relies on (now a spelled-out EMPTY_PROVIDER_HINT),
  FileReader.result is string|ArrayBuffer and was `.split(',')` directly, and
  navigator.standalone is declared as the iOS-only flag it is.
- types/ sits outside src/, so the main build never sees these declarations.

Verified: MAIN_HTML byte-identical at the moment of extraction; full suite
green (1715 pass); npm run build; and a simulated published layout
(dist with a real assets copy instead of the dev symlink) loads and inlines
both files verbatim. The empty state, ⌘K switcher, inspector copy, Sense and
pair panels were re-driven in headless chromium after the move with zero
console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 29c156ef726056707972c5c9a0eefd9bc0f58146)
Two loose ends from the UX pass.

**CSP.** The shell renders untrusted text in a dozen places — tool results,
mail subjects, knowledge-base extracts, an observed agent's output through the
Markdown renderer. Escaping is the first defence and it is applied, but it is
one review away from a hole, and a hole there executes with the full authority
of a page that can drive the agent. A Content-Security-Policy makes an injected
<script> inert regardless.

The page carries two inline <script> blocks, so the policy needs a per-response
nonce rather than 'unsafe-inline' — that is the whole point, since an attacker
cannot guess it. MAIN_HTML becomes renderMainHtml({nonce}), the GET / handler
mints 16 random bytes per response, and MAIN_HTML stays exported (nonce-less,
byte-identical to before) for tests and non-HTTP consumers. no-store on the
shell already guaranteed a fresh document, which is what makes a per-response
nonce safe.

style-src keeps 'unsafe-inline': the client sets style="…" on elements in dozens
of places and nonces do not apply to style attributes. Everything else is as
narrow as the shell actually needs — data:/blob: images and media for attachment
previews and recorded dictation, frame-src 'self' for the Room iframe,
connect-src 'self' for fetch and EventSource, object-src 'none'.

Verified in a real browser against a scratch instance: shell renders, Room
iframe loads, both inline scripts execute (nonce set on each), zero CSP
violations in the console.

**Composer placeholder.** At 375px the textarea is ~160px wide, so
"Talk to Lisa…  (Enter to send · Shift+Enter for newline)" wrapped and clipped
mid-word — and the hint is meaningless on a soft keyboard anyway. Narrow
viewports get the short form, kept correct across rotation by a matchMedia
listener.

**docs/DESIGN_TOKENS.md.** Both palettes, the type scale, spacing, radius,
motion, the focus ring, the breakpoints and the touch-target rule — with the
measured contrast ratios, which lisa-css.test.ts enforces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit e925c2a540a33b2cf8958182ddbaf4c7c62c4b0a)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant